在前面的學習路程中,我們已經建立了單元測試的基礎:
現在我們面臨一個關鍵挑戰:真實世界的程式碼很少是孤立的,它們會與資料庫、檔案系統、網路服務、時間等外部資源產生相依性。這些相依性會讓我們的測試變得緩慢、脆弱、難以重複執行。今天我們要學習如何透過測試替身(Test Double)和 NSubstitute 工具來解決這個問題。
在真實世界的軟體開發中,我們的程式碼很少是完全獨立的。它們會依賴各種外部資源:
// 教學範例:刻意違反 SRP 原則來展示問題
// 實際專案中請避免這樣的設計,應遵循 SOLID 原則
public class FileBackupService
{
public bool BackupFile(string filePath, string backupPath)
{
// 檔案系統操作
if (!File.Exists(filePath)) return false;
// 資料庫記錄
using var connection = new SqlConnection("connectionString");
connection.Open();
// 網路操作
var client = new HttpClient();
var response = client.PostAsync("backup-api", content).Result;
// 時間依賴
var timestamp = DateTime.Now;
return true;
}
}
重要說明:上面的程式碼刻意違反了單一職責原則(SRP),一個類別同時處理檔案操作、資料庫、網路通訊和時間處理。這樣的設計在實際專案中是不好的,但在這裡我們故意這樣寫來突顯不可測試程式碼的問題。稍後我們會展示如何透過 SOLID 原則重構成可測試的設計。
這樣的程式碼在測試時會遇到什麼問題?
讓我們詳細分析為什麼會有這些問題:
IFileSystem
介面DateTime.Now
每次執行都不同,無法預測結果IDateTimeProvider
來控制時間IBackupRepository
抽象資料存取Console.WriteLine
無法在測試中驗證記錄行為ILogger<T>
讓記錄行為可以被測試許多程式開發者在學習單元測試時會卡關,主要原因是不理解相依注入與直接依賴的差異。即使現在 .NET Core 預設整合了 DI(Dependency Injection),仍有許多人對這個概念模糊不清。
// 直接依賴:難以測試
public class OrderService
{
public void ProcessOrder(Order order)
{
// 直接建立依賴物件
var repository = new OrderRepository();
var emailService = new EmailService();
repository.Save(order);
emailService.SendConfirmation(order.Email);
}
}
// 相依注入:容易測試
public class OrderService
{
private readonly IOrderRepository _repository;
private readonly IEmailService _emailService;
public OrderService(IOrderRepository repository, IEmailService emailService)
{
_repository = repository;
_emailService = emailService;
}
public void ProcessOrder(Order order)
{
_repository.Save(order);
_emailService.SendConfirmation(order.Email);
}
}
SOLID 原則不只是 OOP 的最佳實踐,對單元測試也有直接且重要的影響:
沒有遵循 SOLID 原則的程式碼,通常也很難寫出好的單元測試。
在解決相依性問題時,我們需要一個能夠建立測試替身的工具。這類工具在技術領域有幾個正式名稱:
本文中我們會交替使用這些術語,它們指的都是同一類工具。.NET 生態系統中有兩個主要的測試替身框架:Moq 和 NSubstitute。
Moq 是 .NET 平台上歷史悠久且使用最廣泛的模擬框架(Mocking Framework)之一,擁有豐富的功能和龐大的社群支持。
事實上,在 .NET 生態系統中:
- Rhino Mocks 可能是更早期的模擬框架之一 (不過已經在很久之前就已經不再維護)
- TypeMock Isolator 也是早期的商業解決方案
- Moq 雖然不是最早的,但確實是後來最受歡迎和使用最廣泛的開源選擇
優點:
缺點:
// Moq 語法範例
var mock = new Mock<IUserRepository>();
mock.Setup(x => x.GetById(It.IsAny<int>()))
.Returns(new User { Id = 1, Name = "John" });
// 驗證
mock.Verify(x => x.GetById(1), Times.Once);
NSubstitute 是後起之秀的測試替身框架(Test Double Framework),專注於提供簡潔直觀的 API,讓測試程式碼更容易閱讀和維護。
一開始我所接觸到的相依替換工具就是 NSubstitute (通常簡稱 NSub),是在 91 哥的的課程裡所學的,之後在 91 哥所推薦以及翻譯的書籍
單元測試的藝術(第二版) - The Art of Unit Testing: with examples in C# Second Edition
裡,作者也是介紹使用 NSubsititute,後來就一直慣用下去,雖然中途有短暫時間因為工作關係而用了 Moq,雖然說功能上差不多,但使用直覺上就真的差異很大。就是用得不習慣。
優點:
缺點:
// NSubstitute 語法範例
var substitute = Substitute.For<IUserRepository>();
substitute.GetById(Arg.Any<int>())
.Returns(new User { Id = 1, Name = "John" });
// 驗證
substitute.Received(1).GetById(1);
2023 年 8 月,Moq 發生了一個重大的爭議事件,對整個 .NET 社群造成了震撼:
SponsorLink
的相依套件雖然 Moq 的維護者後來移除了爭議的相依套件,並發布了修正版本,但傷害已經造成:
基於上述分析,本教學選擇 NSubstitute 的原因:
NSubstitute 是一個專門為 .NET 平台設計的測試替身框架(Test Double Framework),致力於提供簡潔直觀的測試替身建立體驗。它屬於 Mocking Framework 的範疇,但設計理念更強調簡潔性和可讀性。
Substitute.For<T>()
快速建立介面替身method.Returns(value)
設定回傳值Received()
驗證方法是否被正確呼叫Arg.Any<T>()
彈性的參數匹配Throws()
模擬異常情況測試替身(Test Double)是 Gerard Meszaros 在《xUnit Test Patterns》中提出的概念,就像電影中的替身演員一樣,在測試中替代真實的相依物件。
僅用於滿足方法簽章,不會被實際使用:
public interface IEmailService
{
void SendEmail(string to, string subject, string body, ILogger logger);
}
[Test]
public void ProcessOrder_不使用Email_應成功處理訂單()
{
// Dummy:只是為了滿足參數要求,不會被調用
var dummyLogger = Substitute.For<ILogger>();
var service = new OrderService();
var actual = service.ProcessOrder(order, dummyLogger);
// 不關心 logger 是否被調用
}
提供預先定義的回傳值,用於測試特定情境:
[Test]
public void GetUser_有效的使用者ID_應回傳使用者資料()
{
// Stub:預設回傳值
var stubRepository = Substitute.For<IUserRepository>();
stubRepository.GetById(123).Returns(new User { Id = 123, Name = "John" });
var service = new UserService(stubRepository);
var actual = service.GetUser(123);
Assert.Equal("John", actual.Name);
// 不關心 GetById 被呼叫了幾次
}
有實際功能但簡化的實作,通常用於整合測試:
public class FakeUserRepository : IUserRepository
{
private readonly Dictionary<int, User> _users = new();
public User GetById(int id) => _users.TryGetValue(id, out var user) ? user : null;
public void Save(User user) => _users[user.Id] = user;
}
[Test]
public void CreateUser_建立使用者_應儲存並回傳使用者資料()
{
// Fake:有真實邏輯的簡化實作
var fakeRepository = new FakeUserRepository();
var service = new UserService(fakeRepository);
service.CreateUser(new User { Id = 1, Name = "John" });
var actual = service.GetUser(1);
Assert.Equal("John", actual.Name);
}
記錄被如何呼叫,可以事後驗證:
[Test]
public void CreateUser_建立使用者_應記錄使用者建立資訊()
{
var spyLogger = Substitute.For<ILogger>();
var repository = Substitute.For<IUserRepository>();
var service = new UserService(repository, spyLogger);
service.CreateUser(new User { Name = "John" });
// Spy:驗證是否被正確呼叫
spyLogger.Received(1).LogInformation("User created: John");
}
預設期望的互動行為,測試失敗如果期望沒有滿足:
[Test]
public void RegisterUser_註冊使用者_應發送歡迎郵件()
{
var mockEmailService = Substitute.For<IEmailService>();
var repository = Substitute.For<IUserRepository>();
var service = new UserService(repository, mockEmailService);
service.RegisterUser("john@example.com", "John");
// Mock:驗證特定的互動行為
mockEmailService.Received(1).SendWelcomeEmail("john@example.com", "John");
}
讓我們以一個實際的檔案備份服務為例,展示如何從不可測試的程式碼重構為可測試的設計。
public class FileBackupService
{
public BackupResult BackupFile(string sourcePath, string destinationPath)
{
try
{
// 直接依賴檔案系統
if (!File.Exists(sourcePath))
{
return new BackupResult { Success = false, Message = "Source file not found" };
}
var fileInfo = new FileInfo(sourcePath);
if (fileInfo.Length > 100 * 1024 * 1024) // 100MB
{
return new BackupResult { Success = false, Message = "File too large" };
}
// 直接依賴時間
var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
var backupFileName = $"{Path.GetFileNameWithoutExtension(sourcePath)}_{timestamp}{Path.GetExtension(sourcePath)}";
var fullBackupPath = Path.Combine(destinationPath, backupFileName);
// 執行備份
File.Copy(sourcePath, fullBackupPath);
// 直接依賴資料庫
using var connection = new SqlConnection("Data Source=.;Initial Catalog=BackupDB;Integrated Security=true");
connection.Open();
var command = new SqlCommand(
"INSERT INTO BackupHistory (SourcePath, BackupPath, BackupTime) VALUES (@source, @backup, @time)",
connection);
command.Parameters.AddWithValue("@source", sourcePath);
command.Parameters.AddWithValue("@backup", fullBackupPath);
command.Parameters.AddWithValue("@time", DateTime.Now);
command.ExecuteNonQuery();
return new BackupResult { Success = true, BackupPath = fullBackupPath };
}
catch (Exception ex)
{
// 直接使用 Console.WriteLine(無法測試記錄行為)
Console.WriteLine($"Backup failed: {ex.Message}");
return new BackupResult { Success = false, Message = ex.Message };
}
}
}
public class BackupResult
{
public bool Success { get; set; }
public string Message { get; set; }
public string BackupPath { get; set; }
}
這段程式碼有以下測試問題:
要讓 FileBackupService 變得可測試,我們需要將這些外部依賴抽象化,遵循依賴反轉原則:
解決策略:
IFileSystem
IDateTimeProvider
IBackupRepository
ILogger<T>
進行結構化記錄現在我們要展示如何透過 SOLID 原則,特別是依賴反轉原則,將不可測試的程式碼重構為可測試的設計。
首先,我們為每個外部依賴建立專注且小巧的介面:
public interface IFileSystem
{
bool FileExists(string path);
FileInfo GetFileInfo(string path);
void CopyFile(string sourcePath, string destinationPath);
}
public interface IDateTimeProvider
{
DateTime Now { get; }
}
public interface IBackupRepository
{
Task SaveBackupHistory(string sourcePath, string backupPath, DateTime backupTime);
}
接著,我們重構 FileBackupService,讓它依賴抽象而非具體實作,同時遵循單一職責原則:
public class FileBackupService
{
private readonly IFileSystem _fileSystem;
private readonly IDateTimeProvider _dateTimeProvider;
private readonly IBackupRepository _backupRepository;
private readonly ILogger<FileBackupService> _logger;
public FileBackupService(
IFileSystem fileSystem,
IDateTimeProvider dateTimeProvider,
IBackupRepository backupRepository,
ILogger<FileBackupService> logger)
{
_fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem));
_dateTimeProvider = dateTimeProvider ?? throw new ArgumentNullException(nameof(dateTimeProvider));
_backupRepository = backupRepository ?? throw new ArgumentNullException(nameof(backupRepository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<BackupResult> BackupFileAsync(string sourcePath, string destinationPath)
{
try
{
_logger.LogInformation("Starting backup from {SourcePath} to {DestinationPath}",
sourcePath, destinationPath);
if (!_fileSystem.FileExists(sourcePath))
{
var message = "Source file not found";
_logger.LogWarning("Backup failed: {Message}. Source: {SourcePath}", message, sourcePath);
return new BackupResult { Success = false, Message = message };
}
var fileInfo = _fileSystem.GetFileInfo(sourcePath);
if (fileInfo.Length > 100 * 1024 * 1024) // 100MB
{
var message = "File too large";
_logger.LogWarning("Backup failed: {Message}. File size: {Size} bytes", message, fileInfo.Length);
return new BackupResult { Success = false, Message = message };
}
var timestamp = _dateTimeProvider.Now.ToString("yyyyMMdd_HHmmss");
var backupFileName = $"{Path.GetFileNameWithoutExtension(sourcePath)}_{timestamp}{Path.GetExtension(sourcePath)}";
var fullBackupPath = Path.Combine(destinationPath, backupFileName);
_fileSystem.CopyFile(sourcePath, fullBackupPath);
await _backupRepository.SaveBackupHistory(sourcePath, fullBackupPath, _dateTimeProvider.Now);
_logger.LogInformation("Backup completed successfully. Backup path: {BackupPath}", fullBackupPath);
return new BackupResult { Success = true, BackupPath = fullBackupPath };
}
catch (Exception ex)
{
_logger.LogError(ex, "Backup failed for {SourcePath}", sourcePath);
return new BackupResult { Success = false, Message = ex.Message };
}
}
}
現在我們可以使用 NSubstitute 為重構後的程式碼撰寫測試:
dotnet add package NSubstitute
dotnet add package Microsoft.Extensions.Logging
public class FileBackupServiceTests
{
private readonly IFileSystem _fileSystem;
private readonly IDateTimeProvider _dateTimeProvider;
private readonly IBackupRepository _backupRepository;
private readonly ILogger<FileBackupService> _logger;
private readonly FileBackupService _sut; // System Under Test
public FileBackupServiceTests()
{
_fileSystem = Substitute.For<IFileSystem>();
_dateTimeProvider = Substitute.For<IDateTimeProvider>();
_backupRepository = Substitute.For<IBackupRepository>();
_logger = Substitute.For<ILogger<FileBackupService>>();
_sut = new FileBackupService(_fileSystem, _dateTimeProvider, _backupRepository, _logger);
}
}
[Test]
public async Task BackupFileAsync_來源檔案存在且大小合理_應回傳成功結果()
{
// Arrange - 設定 Stub 行為
var sourcePath = @"C:\source\test.txt";
var destinationPath = @"C:\backup";
var testTime = new DateTime(2024, 1, 1, 12, 0, 0);
_fileSystem.FileExists(sourcePath).Returns(true);
_fileSystem.GetFileInfo(sourcePath).Returns(new FileInfo("dummy") { Length = 1024 });
_dateTimeProvider.Now.Returns(testTime);
// Act
var actual = await _sut.BackupFileAsync(sourcePath, destinationPath);
// Assert
Assert.True(actual.Success);
Assert.Equal(@"C:\backup\test_20240101_120000.txt", actual.BackupPath);
}
[Test]
public async Task BackupFileAsync_來源檔案不存在_應記錄警告並回傳失敗()
{
// Arrange
var sourcePath = @"C:\nonexistent\test.txt";
var destinationPath = @"C:\backup";
_fileSystem.FileExists(sourcePath).Returns(false);
// Act
var actual = await _sut.BackupFileAsync(sourcePath, destinationPath);
// Assert - 驗證狀態
Assert.False(actual.Success);
Assert.Equal("Source file not found", actual.Message);
// Assert - 驗證行為(Mock)
_logger.Received(1).LogWarning(
"Backup failed: {Message}. Source: {SourcePath}",
"Source file not found",
sourcePath);
}
// 建立介面替代
var substitute = Substitute.For<IService>();
// 建立類別替代(需要虛擬成員)
var classSubstitute = Substitute.For<BaseService>();
// 建立多重介面替代
var multiSubstitute = Substitute.For<IService, IDisposable>();
// 基本回傳值
_repository.GetById(1).Returns(new User { Id = 1, Name = "John" });
// 條件回傳值
_calculator.Add(Arg.Any<int>(), Arg.Any<int>()).Returns(x => (int)x[0] + (int)x[1]);
// 針對任何參數回傳
_service.Process(Arg.Any<string>()).Returns("processed");
// 回傳序列值
_generator.GetNext().Returns(1, 2, 3, 4, 5);
// 拋出例外
_service.RiskyOperation().Throws(new InvalidOperationException("Something went wrong"));
// 精確匹配
_service.Process("exact").Returns("result");
// 任意值匹配
_service.Process(Arg.Any<string>()).Returns("result");
// 條件匹配
_service.Process(Arg.Is<string>(x => x.StartsWith("test"))).Returns("result");
// 引數擷取
string capturedArg = null;
_service.Process(Arg.Do<string>(x => capturedArg = x)).Returns("result");
// 驗證被呼叫
_service.Received().Process("test");
// 驗證呼叫次數
_service.Received(2).Process(Arg.Any<string>());
// 驗證未被呼叫
_service.DidNotReceive().Delete(Arg.Any<int>());
// 驗證任意參數呼叫
_service.ReceivedWithAnyArgs().Process(default);
由於 ILogger 的擴展方法特性,需要特殊的驗證方式。在 .NET 的 ILogger 中,LogInformation、LogWarning、LogError 等都是擴展方法,無法直接用 NSubstitute 模擬。我們需要驗證底層的 Log 方法:
[Test]
public async Task BackupFileAsync_檔案不存在_應記錄警告資訊()
{
// Arrange
var sourcePath = @"C:\nonexistent\test.txt";
var destinationPath = @"C:\backup";
_fileSystem.FileExists(sourcePath).Returns(false);
// Act
var actual = await _sut.BackupFileAsync(sourcePath, destinationPath);
// Assert
Assert.False(actual.Success);
// 驗證 ILogger 的 Log 方法被正確呼叫
_logger.Received(1).Log(
LogLevel.Warning,
Arg.Any<EventId>(),
Arg.Is<object>(v => v.ToString().Contains("Source file not found")),
null,
Arg.Any<Func<object, Exception, string>>());
}
[Test]
public async Task BackupFileAsync_發生例外時_應記錄錯誤並回傳失敗()
{
// Arrange
var sourcePath = @"C:\source\test.txt";
var destinationPath = @"C:\backup";
var expectedException = new IOException("Disk full");
_fileSystem.FileExists(sourcePath).Returns(true);
_fileSystem.GetFileInfo(sourcePath).Returns(new FileInfo("dummy") { Length = 1024 });
_fileSystem.CopyFile(Arg.Any<string>(), Arg.Any<string>()).Throws(expectedException);
// Act
var actual = await _sut.BackupFileAsync(sourcePath, destinationPath);
// Assert
Assert.False(actual.Success);
Assert.Equal("Disk full", actual.Message);
// 驗證錯誤記錄
_logger.Received(1).Log(
LogLevel.Error,
Arg.Any<EventId>(),
Arg.Is<object>(v => v.ToString().Contains("Backup failed")),
expectedException,
Arg.Any<Func<object, Exception, string>>());
}
注意:ILogger 的擴展方法驗證比較複雜,實務上也可以考慮使用 Microsoft.Extensions.Logging.Testing 套件來簡化測試。
public class FileBackupServiceTests
{
private readonly IFileSystem _fileSystem;
private readonly IDateTimeProvider _dateTimeProvider;
private readonly IBackupRepository _backupRepository;
private readonly ILogger<FileBackupService> _logger;
private readonly FileBackupService _sut;
public FileBackupServiceTests()
{
_fileSystem = Substitute.For<IFileSystem>();
_dateTimeProvider = Substitute.For<IDateTimeProvider>();
_backupRepository = Substitute.For<IBackupRepository>();
_logger = Substitute.For<ILogger<FileBackupService>>();
_sut = new FileBackupService(_fileSystem, _dateTimeProvider, _backupRepository, _logger);
}
[Test]
public async Task BackupFileAsync_來源檔案存在且備份成功_應記錄資訊並回傳成功結果()
{
// Arrange
var sourcePath = @"C:\source\document.pdf";
var destinationPath = @"C:\backup";
var testTime = new DateTime(2024, 1, 15, 14, 30, 0);
var expectedBackupPath = @"C:\backup\document_20240115_143000.pdf";
_fileSystem.FileExists(sourcePath).Returns(true);
_fileSystem.GetFileInfo(sourcePath).Returns(new FileInfo("dummy") { Length = 1024 * 1024 }); // 1MB
_dateTimeProvider.Now.Returns(testTime);
// Act
var actual = await _sut.BackupFileAsync(sourcePath, destinationPath);
// Assert - 狀態驗證
Assert.True(actual.Success);
Assert.Equal(expectedBackupPath, actual.BackupPath);
// Assert - 行為驗證
_fileSystem.Received(1).CopyFile(sourcePath, expectedBackupPath);
await _backupRepository.Received(1).SaveBackupHistory(sourcePath, expectedBackupPath, testTime);
// Assert - 記錄行為驗證
_logger.Received(1).LogInformation(
"Starting backup from {SourcePath} to {DestinationPath}",
sourcePath, destinationPath);
_logger.Received(1).LogInformation(
"Backup completed successfully. Backup path: {BackupPath}",
expectedBackupPath);
}
[Test]
public async Task BackupFileAsync_來源檔案大小超過限制_應記錄警告並回傳失敗()
{
// Arrange
var sourcePath = @"C:\source\largefile.zip";
var destinationPath = @"C:\backup";
var largeFileSize = 200 * 1024 * 1024; // 200MB
_fileSystem.FileExists(sourcePath).Returns(true);
_fileSystem.GetFileInfo(sourcePath).Returns(new FileInfo("dummy") { Length = largeFileSize });
// Act
var actual = await _sut.BackupFileAsync(sourcePath, destinationPath);
// Assert
Assert.False(actual.Success);
Assert.Equal("File too large", actual.Message);
_logger.Received(1).LogWarning(
"Backup failed: {Message}. File size: {Size} bytes",
"File too large",
largeFileSize);
// 確保沒有執行備份操作
_fileSystem.DidNotReceive().CopyFile(Arg.Any<string>(), Arg.Any<string>());
await _backupRepository.DidNotReceive().SaveBackupHistory(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<DateTime>());
}
[Test]
public async Task BackupFileAsync_資料庫儲存歷史記錄時拋出例外_應記錄錯誤並回傳失敗()
{
// Arrange
var sourcePath = @"C:\source\test.txt";
var destinationPath = @"C:\backup";
var expectedException = new InvalidOperationException("Database connection failed");
_fileSystem.FileExists(sourcePath).Returns(true);
_fileSystem.GetFileInfo(sourcePath).Returns(new FileInfo("dummy") { Length = 1024 });
_dateTimeProvider.Now.Returns(new DateTime(2024, 1, 1));
_backupRepository.SaveBackupHistory(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<DateTime>())
.Throws(expectedException);
// Act
var actual = await _sut.BackupFileAsync(sourcePath, destinationPath);
// Assert
Assert.False(actual.Success);
Assert.Equal("Database connection failed", actual.Message);
_logger.Received(1).LogError(
expectedException,
"Backup failed for {SourcePath}",
sourcePath);
}
}
// X 錯誤:模擬值物件
var badDate = Substitute.For<DateTime>(); // DateTime 是值類型
// O 正確:模擬抽象概念
var dateProvider = Substitute.For<IDateTimeProvider>();
// X 錯誤:測試實作細節
[Test]
public void ProcessOrders_處理訂單_應呼叫Repository三次()
{
_service.ProcessOrders(orders);
_repository.Received(3).Save(Arg.Any<Order>());
}
// O 正確:測試行為結果
[Test]
public void ProcessOrders_處理訂單_應儲存所有有效訂單()
{
var actual = _service.ProcessOrders(orders);
Assert.Equal(2, actual.SavedCount);
}
// 狀態驗證 vs 行為驗證
[Test]
public void ProcessOrder_處理訂單_應設定訂單狀態為已處理()
{
// 狀態驗證:檢查結果
var actual = _service.ProcessOrder(order);
Assert.Equal(OrderStatus.Processed, actual.Status);
// 行為驗證:檢查互動(僅在必要時使用)
_emailService.Received(1).SendConfirmation(order.CustomerEmail);
}
當測試案例變多時,重複的 Substitute 設定會讓測試程式碼變得冗長且難以維護。這時候我們可以建立基底測試類別來管理共用的設定:
public class OrderServiceTestsBase
{
protected readonly IOrderRepository Repository;
protected readonly IEmailService EmailService;
protected readonly ILogger<OrderService> Logger;
protected readonly OrderService Sut;
protected OrderServiceTestsBase()
{
Repository = Substitute.For<IOrderRepository>();
EmailService = Substitute.For<IEmailService>();
Logger = Substitute.For<ILogger<OrderService>>();
Sut = new OrderService(Repository, EmailService, Logger);
}
// 有效訂單設定
protected void SetupValidOrder()
{
Repository.GetById(Arg.Any<int>()).Returns(new Order { Id = 1, Status = OrderStatus.Pending });
}
// Email 服務成功設定
protected void SetupEmailServiceSuccess()
{
EmailService.SendConfirmation(Arg.Any<string>()).Returns(true);
}
}
// 繼承基底類別,避免重複設定
public class OrderServiceTests : OrderServiceTestsBase
{
[Test]
public void ProcessOrder_有效訂單_應回傳成功結果()
{
// Arrange
SetupValidOrder();
SetupEmailServiceSuccess();
// Act
var actual = Sut.ProcessOrder(1);
// Assert
actual.Success.Should().BeTrue();
}
}
使用基底類別的優點:
注意事項:
很多人搞混 Mock 和 Stub,讓我們用實際例子來看差異:
[Test]
public void CalculateDiscount_高級會員_應回傳20%折扣()
{
// Stub:只關心回傳值,用於設定測試情境
var stubCustomerService = Substitute.For<ICustomerService>();
stubCustomerService.GetCustomerType(123).Returns(CustomerType.Premium);
var service = new PricingService(stubCustomerService);
var discount = service.CalculateDiscount(123, 1000);
// 只驗證結果狀態
Assert.Equal(200, discount); // 20% of 1000
}
驗證 相依物件
的行為互動
[Test]
public void ProcessPayment_成功付款_應記錄交易資訊()
{
// Mock:關心 ILogger<PaymentService> 是否正確互動
var mockLogger = Substitute.For<ILogger<PaymentService>>();
var stubPaymentGateway = Substitute.For<IPaymentGateway>();
stubPaymentGateway.ProcessPayment(Arg.Any<decimal>()).Returns(PaymentResult.Success);
var service = new PaymentService(stubPaymentGateway, mockLogger);
service.ProcessPayment(100);
// 驗證正確的互動行為
mockLogger.Received(1).LogInformation(
"Payment processed: {Amount} - Result: {Result}",
100,
PaymentResult.Success);
}
應該替代的:
不應該替代的:
使用 Stub 的時機:
使用 Mock 的時機:
怪味道:測試設定過於複雜
// 壞味道:設定過多的 Substitute
var sub1 = Substitute.For<IService1>();
var sub2 = Substitute.For<IService2>();
var sub3 = Substitute.For<IService3>();
// ... 更多設定
// 解決:重新思考類別職責,可能違反了 SRP
怪味道:測試與實作強耦合
// 壞味道:測試實作細節
_repository.Received(1).Save(Arg.Any<User>());
_repository.Received(1).Update(Arg.Any<User>());
_repository.Received(1).Delete(Arg.Any<int>());
// 解決:關注行為結果而非實作步驟
Assert.Equal(expectedUsers, actualUsers);
測試替身是單元測試中不可或缺的技術,NSubstitute 提供了簡潔優雅的語法來建立各種類型的替身。重點在於:
透過今天的 ILogger 模擬練習,明天我們將深入探討:
明天我們將繼續探討測試中的輸出與記錄主題,進一步提升測試的診斷能力。
範例程式碼:
這是「重啟挑戰:老派軟體工程師的測試修練」的第七天。明天會介紹 Day 08:測試輸出與記錄 - xUnit ITestOutputHelper 與 ILogger。